Skip to content

Add support for HTTP/2 - #13039

Open
Moist-Cat wants to merge 21 commits into
aio-libs:masterfrom
Moist-Cat:master
Open

Add support for HTTP/2#13039
Moist-Cat wants to merge 21 commits into
aio-libs:masterfrom
Moist-Cat:master

Conversation

@Moist-Cat

@Moist-Cat Moist-Cat commented Jul 2, 2026

Copy link
Copy Markdown

What do these changes do?

Add HTTP/2 client support.

Why

Faster I/O bound operations (e.g., many requests to the same host) via multiplexing (handling several streams/requests inside a single connection).

How

  1. Use the environment variable AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=1 to allow h2 negotiation via ALPN during the TLS handshake.
  2. ResponseHandler was substituted by a wrapper that conditionally switches protocols depending on the negotiated protocol.
  3. I forced unconditional connection reuse for HTTP/2 connections since pooling is now unnecessary. This doesn't affect HTTP/1.1 connections. To make this possible, however, I had to use a Semaphore to avoid race conditions.

This means opening many HTTP/1.1 connections in parallel is now slower because it's done sequentially. That said, to know if connections can be pooled or not it's only necessary to wait until the first connection is done. Once it's known whether the host supports HTTP/2 or not, the rest of the requests can be done in parallel so it's possible to mitigate this performance hit substantially.

Backward compatibility

Opt-in via AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=1.

Testing

%95 coverage, benchmarks (%50 latency reduction for 99 requests, see below), and integration tests against real servers (~100).

Dependencies

hpack

Is it a substantial burden for the maintainers to support this?

Yes.

Related issue number

refs #5999

The implementation is self-contained, the changes to the current codebase are minimal and backwards compatible. That said, I make use of some black magic with __getattr__ to be able to conditionally switch protocols.

Missing features (to the date):

  • Proxies
  • Chunking
  • CONTINUATION frames for very large headers
  • h2c (cleartext) not supported.
  • Ensure all the high-level configuration/parameters work (or make sense for) with HTTP/2 as well

Moist-Cat and others added 2 commits July 2, 2026 19:38
    This implementation is backwards compatible, functional, but still
incomplete.
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/response.py Fixed
Comment thread aiohttp/http_protocol.py
self._handler: Optional[asyncio.Protocol] = None

# ---- Transport callbacks forwarded to the real handler ----
def connection_made(self, transport: asyncio.BaseTransport) -> None:
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.54647% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.94%. Comparing base (d5d068c) to head (742899f).
⚠️ Report is 3 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
aiohttp/http2/connection.py 94.78% 9 Missing and 8 partials ⚠️
tests/http2/test_http2.py 98.74% 7 Missing and 3 partials ⚠️
aiohttp/connector.py 80.00% 1 Missing and 1 partial ⚠️
aiohttp/http2/response.py 96.49% 1 Missing and 1 partial ⚠️
aiohttp/http_protocol.py 93.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #13039      +/-   ##
==========================================
- Coverage   98.98%   98.94%   -0.04%     
==========================================
  Files         132      139       +7     
  Lines       49073    50403    +1330     
  Branches     2553     2647      +94     
==========================================
+ Hits        48576    49873    +1297     
- Misses        373      392      +19     
- Partials      124      138      +14     
Flag Coverage Δ
Autobahn 22.29% <28.47%> (+0.16%) ⬆️
CI-GHA 98.86% <97.54%> (-0.04%) ⬇️
OS-Linux 98.64% <97.54%> (-0.04%) ⬇️
OS-Windows 97.04% <97.47%> (+0.01%) ⬆️
OS-macOS 97.91% <97.54%> (-0.02%) ⬇️
Py-3.10 98.10% <97.54%> (-0.02%) ⬇️
Py-3.11 98.35% <97.54%> (-0.03%) ⬇️
Py-3.12 98.44% <97.54%> (-0.03%) ⬇️
Py-3.13 98.41% <97.54%> (-0.04%) ⬇️
Py-3.14 98.43% <97.54%> (-0.03%) ⬇️
Py-3.14t 97.55% <97.54%> (-0.01%) ⬇️
Py-pypy-3.11 97.39% <97.54%> (+<0.01%) ⬆️
VM-macos 97.91% <97.54%> (-0.02%) ⬇️
VM-ubuntu 98.64% <97.54%> (-0.04%) ⬇️
VM-windows 97.04% <97.47%> (+0.01%) ⬆️
cython-coverage 37.90% <34.30%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Jul 3, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 9.65%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 8 regressed benchmarks
✅ 76 untouched benchmarks
⏩ 83 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_one_thousand_round_trip_websocket_binary_messages[tcp-small] 46.8 ms 54.4 ms -14.11%
test_one_thousand_round_trip_websocket_text_messages 48.1 ms 55.3 ms -12.95%
test_one_hundred_simple_get_requests_multiple_methods_route 137 ms 149.9 ms -8.6%
test_one_hundred_simple_get_requests_alternating_clients 140.5 ms 153.5 ms -8.5%
test_one_hundred_simple_get_requests[tcp] 137.9 ms 150.6 ms -8.43%
test_one_hundred_get_requests_with_1024_content_length_payload 148.3 ms 161.6 ms -8.2%
test_one_hundred_get_requests_with_1024_chunked_payload[tcp] 150.2 ms 163.5 ms -8.13%
test_ten_web_middlewares 146.7 ms 159.5 ms -8.02%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing Moist-Cat:master (742899f) with master (d5d068c)2

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on master (c0ef574) during the generation of this report, so d5d068c was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Comment thread tests/http2/test_http2.py Fixed
Comment thread docs/conf.py

try:
import sphinxcontrib.spelling # noqa
import sphinxcontrib.spelling
Moist-Cat and others added 2 commits July 4, 2026 21:52
    It was necessary to add a semaphore to ensure the requests connect
sequentially to the hosts and reuse connections when necessary. HTTP/2
uses a single connection per host.
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
@Moist-Cat

Moist-Cat commented Jul 5, 2026

Copy link
Copy Markdown
Author

I ran tests against remote servers (httpbin.org) to verify HTTP/2 indeed reduces latency.

HTTP/2 Performance Test Results

System Specs:

  • CPU: Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz (8 cores)
  • Memory: 7892016 KB
  • Python: 3.14.2
  • aiohttp: 4.0.0a2.dev0

Test Configuration:

  • Concurrency per batch: 99
  • Number of batches: 30
  • Total requests per version: 2970

Batch Mean Latency (seconds)

Version Mean Std Dev P50 P95 P99
HTTP/1.1 1.3928 0.9116 1.2004 2.9433 4.5932
HTTP/2 0.4821 0.1588 0.4502 0.6966 1.0516

Individual Request Latency Distribution

Version Mean P50 P95 P99
HTTP/1.1 1.3928 0.9819 3.8822 7.1815
HTTP/2 0.4821 0.4517 0.7857 1.3151

Statistical Analysis

  • Welch’s t‑test on batch means:
    t = 5.390, p = 0.000007
  • Cohen’s d: 1.392
  • Assumption: Measurement errors (batch means) are approximately normally distributed (reasonable with 30 batches by the Central Limit Theorem).

A simple bar chart with the means (results vary because they are from a second test):
bar_chart

We lose efficiency in CPU bound tasks (see #13039 (comment)) but I/O bound tasks are significantly faster. This is specially true for batch requests that require multiple TCP connections to the same host.

@Moist-Cat

Copy link
Copy Markdown
Author

I would like to know if the trade-offs (I/O vs CPU) are acceptable before writing the docs.

@Moist-Cat
Moist-Cat marked this pull request as ready for review July 6, 2026 00:29
@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: I ran tests against remote servers (httpbin.org) to verify HTTP/2 indeed reduces latency.

HTTP/1.1 regression not inherent to h2. Caused by global Semaphore(1) wrapping every connector.connect() in _connect_and_send_request. Serializes all connection setup, h1 included — hence the ~8% CodSpeed hit on non-h2 benchmarks. Scope the semaphore to first-connect-per-unknown-host under the flag; h1 parallelism returns.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: I would like to know if the trade-offs (I/O vs CPU) are acceptable before writing the docs.

Bigger blocker than the CPU/IO trade-off. h2 path returns Http2Response, not ClientResponse. Breaks .json(), .text(), cookies, raise_for_status, redirects, middleware. Hold the docs. Resolve response integration, the connector-slot leak, and the semaphore serialization first. The default-path CPU cost is the semaphore — removable, not intrinsic.

@aiolibsbot

aiolibsbot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@Moist-Cat

Moist-Cat commented Jul 6, 2026

Copy link
Copy Markdown
Author

Either inheriting from or using ClientResponse directly appears to be the most architecturally sound approach (even though inheritance in this case constitutes a violation of the Liskov substitution principle), however this class is deeply coupled with HTTP/1.1. For example, the _start method calls protocol.read() from connection. This is incompatible with h2 because the protocol handles many streams, not just the one associated to the response and raise_for_status depends on reason which doesn't exist in h2. A better solution is to create a "doppelganger" class that mimics the public interface, which is precisely what Http2Response is. The public interface is the same so the high-level functionalities that rely on these (e.g., session cookies, redirects) keep work regardless of the underlying protocol. In other words, the API is backwards compatible as far as I tested.

Regarding the Semaphore, I believe simply allowing parallel connections when the flag is not set would be the best approach here since the general solution (i.e., verifying if the host supports h2) requires tracking the hosts in TCPConnector which doesn't seem trivial at glance. Improving performance can be done in another PR after the protocol is integrated and working.

To deal with limit for h2, it's important to decide whether to count streams (current behaviour) or TCP connections (calling _release after every successful connection while keeping the same protocol in the _acquired set).

@Dreamsorcerer Dreamsorcerer added this to the 4.0 milestone Jul 12, 2026
@Dreamsorcerer

Copy link
Copy Markdown
Member

Either inheriting from or using ClientResponse directly appears to be the most architecturally sound approach (even though inheritance in this case constitutes a violation of the Liskov substitution principle), however this class is deeply coupled with HTTP/1.1.

At a glance, it looks like there's still a lot of shared code. I'd suggest a refactor that produces a generic ClientResponse and then have both versions subclass it. So we end up with ClientResponseHttp1 and ClientResponseHttp2 or similar.

It would really help us to review if that refactor was in a separate PR.

Also consider that we can make modest breaking changes in v4, if that's required for a clean solution.

I don't have capacity to review the PR in full yet, but I'll come back round to it before the 3.15 release. Might be worth a couple of rounds with the bot before then. Thanks for looking into this complex feature.

@Dreamsorcerer

Copy link
Copy Markdown
Member

Also, we generally don't use envvars, I'd probably add this as a ClientSession parameter instead.

Comment thread aiohttp/client.py
import json
import os
import sys
import time
@Moist-Cat

Copy link
Copy Markdown
Author

At a glance, it looks like there's still a lot of shared code. I'd suggest a refactor that produces a generic ClientResponse and then have both versions subclass it. So we end up with ClientResponseHttp1 and ClientResponseHttp2 or similar.

It would really help us to review if that refactor was in a separate PR.

I will open another PR with the refactored code.

Also, we generally don't use envvars,

I'm aware but I would rather not change more lines than necessary before the PR is ready to merge to avoid conflicts. Switching to a session-level parameter can be done later.


# Send initial SETTINGS (our preferences)
settings_payload = struct.pack(
"!H I", Setting.ENABLE_PUSH, 0 # disable server push

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But push is enabled in settings.py:DEFAULT_SETTINGS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — real inconsistency. initiate_connection sends ENABLE_PUSH=0 on wire. local_settings still holds 1. Two sources of truth diverge.

Worse consequence downstream: _dispatch_frame only logs on PUSH_PROMISE. RFC 9113 §6.6 requires connection error when push disabled. Silent drop also skips HPACK decode, so peer's dynamic table diverges from ours. Every later header block corrupts. Same argument applies to dropped CONTINUATION frames.

Suggest separate local defaults with ENABLE_PUSH: 0, plus _protocol_error() on both frame types.

@egormanga

Copy link
Copy Markdown

In short, what impedes the support for h2c currently? I've familiarized myself with the patch and am willing to take on that.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Dreamsorcerer: Also, we generally don't use envvars, I'd probably add this as a ClientSession parameter instead.

Strong argument for session parameter beyond convention. _SSL_CONTEXT_VERIFIED / _SSL_CONTEXT_UNVERIFIED build at connector.py import time. Env var read once, then frozen.

So os.environ[...] = "1" after import aiohttp silently does nothing. No error, no warning. monkeypatch.setenv in tests equally inert.

Also current gate leaks: os.getenv(..., False) treats =0 as enabled, and dispatch keys off negotiated ALPN, not the flag — user-supplied SSLContext advertising h2 activates the path regardless. Session parameter removes all three problems at once.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: Either inheriting from or using ClientResponse directly appears to be the most architecturally sou...

Coupling point noted, and @Dreamsorcerer's shared-base-class split addresses it — _start stays HTTP/1.1-only, reason moves to the subclass.

On "public interface same": not yet. Concrete gaps found: resp.history missing (only _history; client.py:872 writes it, client.py:1095 reads .history), no content StreamReader, no request_info/content_type/charset/links/raw_headers. text() hardcodes utf-8 instead of Content-Type charset. json() skips content-type validation. ok is 200 <= status < 400 versus status < 400.

Full list in the deferred finding — checklist for the refactor PR.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: > At a glance, it looks like there's still a lot of shared code. I'd suggest a refactor that produce...

Agreed on scope split. One correction on semaphore though.

The unconditional Semaphore(1) costs more than throughput. connector.connect() awaits _wait_for_available_connection() when a pool saturates — up to timeout.connect. Holding a process-wide semaphore across that stalls every unrelated host behind one busy host.

So flag-gating it isn't just perf tuning; it removes a new cross-host stall. Worth doing in this PR.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@egormanga: In short, what impedes the support for h2c currently? I've familiarized myself with the patch and ...

Nothing structural blocks h2c. Http2Connection needs no TLS.

Missing pieces: (1) prior-knowledge entry point — skip ALPN, call initiate_connection() on plain transport; (2) Upgrade-header path (RFC 7540 §3.2) with HTTP2-Settings and stream 1 half-closed; (3) HttpDispatcherProtocol.connection_made currently defaults to ResponseHandler whenever ssl_object is None, so it needs an explicit non-TLS opt-in signal; (4) client.py re-derives ALPN from the transport — same problem.

Suggest waiting for the ClientResponse refactor and the connection-accounting fix to land first. Both touch the same dispatch code.

@aiolibsbot

aiolibsbot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

PR Review — Add support for HTTP/2

Substantial, well-tested HTTP/2 core — but the integration layer still regresses the default HTTP/1.1 path and the frame parser has several inputs that crash the connection.

What's genuinely strong here:

  • http2/connection.py is a real RFC 9113 implementation, not a sketch: frame reassembly and dispatch, HPACK via the hpack library, a proper stream state machine with a transition table, SETTINGS/PING/GOAWAY/WINDOW_UPDATE handling, and both connection- and stream-level flow control.
  • The test suite goes well beyond "it works": black-box frame-level compliance tests, state-machine edge cases, deadlock/race tests for concurrent send_data, and end-to-end ClientSession tests driven through a fake transport.
  • The connector accounting was reworked since the last round to use _release instead of writing _conns directly, and the module-level logger.setLevel(DEBUG) is gone. Both were the right calls.
  • The PR description is honest about what is missing (proxies, chunking, CONTINUATION, h2c) and backs the perf claim with measurements.

Blocking on the default path (affects every aiohttp user, flag or no flag):

  • Semaphore(1) around connector.connect() serializes all connection establishment and head-of-line-blocks unrelated hosts behind a request waiting on another host's limit_per_host — a stall of up to the full connect timeout.
  • HttpDispatcherProtocol.__getattribute__ puts an interpreted attribute-forwarding hook on the hottest object in the client, hit on every data_received and every conn.protocol.* access.

Blocking on the h2 path:

  • Two _release() calls per request grow connector._conns[key] by one duplicate entry per request (unbounded) and leave _acquired_per_host desynced, silently disabling limit_per_host; a single failed request also tears down the shared connection for all concurrent streams.
  • :authority is built from url.host, dropping the port and using the IDNA-decoded form — wrong vhost routing on non-default ports.
  • Four parser inputs raise out of data_received and strand every pending stream: unknown SETTINGS ids 0x0/0x7 (which RFC 9113 says MUST be ignored), non-UTF-8 GOAWAY debug data, truncated GOAWAY/RST_STREAM/WINDOW_UPDATE payloads, and pad_length >= len(payload) silently truncating the body instead of erroring.
  • No MAX_FRAME_SIZE enforcement and an unbounded reassembly buffer.
  • The opt-in is leaky in both directions: os.getenv(..., False) means =0 enables h2, the env var is only read at import time so setting it from Python is a no-op, and a user-supplied SSLContext advertising h2 routes onto the experimental path with the flag unset.
  • import time / from collections import deque in client.py and the # noqa removal in docs/conf.py are unused-import lint failures that will red the CI before review.

The Http2Response-vs-ClientResponse question is not counted against this PR — @Dreamsorcerer asked for that refactor in a separate PR and @Moist-Cat agreed; it's recorded as a [Deferred] suggestion with a concrete gap list for that follow-up.


✅ Resolved since last review (6)

Previously-flagged issues verified fixed
  • aiohttp/client.py:253 HTTP/2 path returns Http2Response, not ClientResponse — breaks the public API contract
  • aiohttp/connector.py:329 Global Semaphore(1) serializes ALL connection establishment, including HTTP/1.1
  • aiohttp/connector.py:868 os.getenv truthiness check enables h2 for any value, including "0" and "false"
  • aiohttp/client.py:254 Manual _conns insertion + conn._protocol=None leaks connector accounting slots
  • aiohttp/http2/connection.py:44 Module sets logger level to DEBUG at import; frame send/recv logs eagerly on the hot path
  • aiohttp/http2/connection.py:209 Incoming frame handling: no frame-size cap, padding underflow, and a shared flow-control Event across streams

🔴 Blocking

1. Global Semaphore(1) serializes every connect — including HTTP/1.1 — and head-of-line-blocks unrelated hosts
aiohttp/client.py:240-244

async with connector.sem: wraps connector.connect() unconditionally, and self.sem = asyncio.Semaphore(1) is created for every BaseConnector regardless of whether AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS is set (connector.py:410).

Two distinct harms, both on the default path every aiohttp user hits:

  • Serialized handshakes. N concurrent requests that each need a fresh connection now complete their TCP+TLS handshakes strictly one at a time. This is the measurable regression on the non-h2 test_one_hundred_simple_get_requests* benchmarks.
  • Head-of-line blocking across hosts. connect() does not return quickly when the pool is saturated — it awaits _wait_for_available_connection() (connector.py:713), which blocks until timeout.connect. A single request stalled on host A's limit_per_host therefore holds the process-wide semaphore and blocks every request to unrelated hosts B, C, D behind it. That is a new stall of up to the full connect timeout on traffic that has nothing to do with HTTP/2.

How to fix: as you proposed in the thread, at minimum skip the semaphore entirely when the experimental flag is off. Better: move the coordination inside the connector, key it per ConnectionKey, and only hold it across the first connect to a host whose ALPN result is still unknown — never across _wait_for_available_connection().

(Noting your comment that the general host-tracking optimisation is follow-up work — that part is fine to defer. The unconditional serialization of the HTTP/1.1 path is what blocks here, and you already agreed it should be gated.)

        async with connector.sem:
            # at most just one
            conn = await connector.connect(
                req, traces=req._traces, timeout=req._timeout
            )
2. [Pre-Existing Issue] __getattribute__/__setattr__ forwarding wrapper sits on the hot path of every connection
aiohttp/http_protocol.py:40-52

HttpDispatcherProtocol now replaces ResponseHandler as the factory for all connections (connector.py:379), and every attribute access on it goes through a Python-level __getattribute__ that does a str.startswith plus a set-membership test before delegating.

Why it matters:

  • data_received is dispatched by asyncio through this wrapper on every TCP read, and conn.protocol.<attr> is touched many times per request (transport, should_close, writing_paused, set_response_params, closed, …). Adding an interpreted __getattribute__ to the single hottest object in the client is a permanent tax on the HTTP/1.1 path — the path this feature is supposed to leave untouched.
  • __setattr__ forwards to self._handler, which is None until connection_made. Any attribute access before then raises AttributeError: 'NoneType' object has no attribute .... Today this is masked rather than safe: CPython's sslproto.SSLProtocol._set_app_protocol does hasattr(app_protocol, 'get_buffer'), and hasattr swallows the AttributeError, so it happens to return False — by accident, not by design.
  • Subclassing plain asyncio.Protocol also makes isinstance(proto, BufferedProtocol) permanently False, so the wrapper structurally blocks aiohttp from ever adopting the buffered-protocol read path on the client. (ResponseHandler is not a BufferedProtocol today, so this is latent, not a live regression.)
  • __slots__ on a subclass of asyncio.Protocol (which has no __slots__) does not remove __dict__, so it buys nothing here.

How to fix: dispatch explicitly rather than by proxy. Implement the six asyncio.Protocol callbacks as thin forwarders and have the connector unwrap to the concrete handler once ALPN is known (e.g. return the real handler from _wrap_create_connection / _start_tls_connection instead of keeping the wrapper alive for the lifetime of the connection). That removes the per-access cost from the HTTP/1.1 path entirely and makes the type honest, which also removes the need for the two # type: ignore[return-value] HACKs.

    def __getattribute__(self, name: str) -> Any:
        if not name.startswith("__") and name not in {
            "connection_made",
            "__getattribute__",
            "_handler",
            "_transport",
            "_loop",
        }:
            return getattr(self._handler, name)
        return super().__getattribute__(name)

🟡 Important

1. :authority drops the port and uses the IDNA-decoded host
aiohttp/http2/connection.py:484-490

(":authority", url.host) builds the authority pseudo-header from yarl.URL.host, which is the decoded host and never carries the port.

Why it matters:

  • Requests to a non-default port (https://example.com:8443/) send :authority: example.com. RFC 9113 §8.3.1 requires the authority to include the port when it is not the scheme default. Virtual-hosted and port-multiplexed servers will route to the wrong vhost or reject the request. The bug is unrecoverable at the server because send_request also strips the client's host header (line ~500), so there is no fallback.
  • For IDN hosts, url.host returns the Unicode form (пример.рф) where raw_host returns the ASCII/punycode form. Emitting non-ASCII in a pseudo-header is a protocol violation.

How to fix: use url.raw_authority (or raw_host plus url.explicit_port), matching how the HTTP/1.1 path builds the Host header.

Note this is untested: url_mock() in tests/http2/test_http2.py has no port and no raw_host, so no test exercises either case.

        req_headers = [
            (":method", method),
            (":path", path_and_query),
            (":scheme", url.scheme),
            (":authority", url.host),
        ]
2. Unknown SETTINGS identifiers 0x0 and 0x7 raise ValueError out of data_received
aiohttp/http2/connection.py:275-280

The guard accepts any identifier in 0..9, but Setting only defines 1,2,3,4,5,6,8,9. A SETTINGS frame carrying identifier 0x0 or 0x7 therefore reaches Setting(identifier) and raises ValueError: 0 is not a valid Setting, which propagates out of data_received — i.e. out of an asyncio transport callback, where it becomes an unhandled exception and leaves the connection wedged with all pending stream futures never resolved.

Why it matters: RFC 9113 §6.5.2 says an endpoint receiving an unknown or unsupported setting identifier MUST ignore that setting. Identifier 0x7 is unassigned and is exactly the kind of value a GREASE-style or future-extension server emits, so a spec-compliant peer can break the connection.

How to fix: replace the numeric range check with membership, e.g.

try:
    setting = Setting(identifier)
except ValueError:
    logger.debug("Ignoring unknown setting identifier %d", identifier)
    continue

(identifier < 0 is also dead — !H is unsigned.)

            identifier, value = struct.unpack("!H I", payload[i : i + 6])
            if identifier < 0 or identifier > 9:
                logger.warning("Unknown setting identifier %d", identifier)
                continue
            setting = Setting(identifier)
3. GOAWAY handler crashes on non-UTF-8 debug data or a short payload
aiohttp/http2/connection.py:315-325

Two server-controlled inputs can raise out of data_received:

  • extra.decode() is evaluated eagerly as a logger.info argument. GOAWAY's Additional Debug Data field is opaque octets (RFC 9113 §6.8) — any non-UTF-8 byte raises UnicodeDecodeError. Because the connection is going away anyway, the failure silently strands every stream future instead of resolving them with ConnectionError.
  • struct.unpack("!I I", payload[:8]) raises struct.error if a peer sends a truncated GOAWAY.

The same fixed-width-unpack assumption exists in _handle_rst_stream_frame (struct.unpack("!I", payload) needs exactly 4 bytes) and _handle_window_update_frame.

Why it matters: these are the paths that run when a connection is already failing, so a crash here converts "connection closed cleanly" into "all in-flight requests hang until timeout".

How to fix: length-check the payload before unpacking, and log the debug data with %r on the raw bytes (or extra.decode(errors="replace")) using lazy %s args. Also mask the reserved bit and reject a zero increment in WINDOW_UPDATE per §6.9.

        self._goaway_received = True
        last_stream_id, error_code = struct.unpack("!I I", payload[:8])
        extra = payload[8:]
        self._last_stream_id = last_stream_id
        self._error_code = error_code
        logger.info(
            "GOAWAY received: last_stream=%d, error=%d, extra=%s",
            last_stream_id,
            error_code,
            extra.decode(),
        )
4. DATA padding length is not validated — body is silently truncated instead of rejected
aiohttp/http2/connection.py:198-205

data = payload[pos : len(payload) - pad_length] with no check that pad_length < len(payload).

If a peer sends pad_length >= len(payload) the end index goes negative, Python slices from the front, and the handler silently delivers a truncated (or empty) body to the caller. RFC 9113 §6.1 states this MUST be treated as a connection error of type PROTOCOL_ERROR.

Why it matters: a buggy or hostile server can silently corrupt response bodies with no error surfaced to the application — the worst failure mode for an HTTP client, since callers have no way to detect it. The # padding might be too long comment shows the case was noticed but not handled.

How to fix:

if flags & FlagData.PADDED:
    pad_length = payload[0]
    pos = 1
    if pad_length >= len(payload):
        self._protocol_error()
        return

The same applies to the PRIORITY-flag branch in _handle_headers_frame, which does an unchecked payload = payload[5:].

        # padding might be too long
        data = payload[pos : len(payload) - pad_length]
5. Frame length is never checked against MAX_FRAME_SIZE and the reassembly buffer is unbounded
aiohttp/http2/connection.py:117-129

data_received reads the 24-bit length field and buffers until the whole frame arrives, without validating it against the MAX_FRAME_SIZE this client advertises (16384 by default, settings.py).

Why it matters: a peer can declare a 16 MB frame and dribble it, and self._frame_buffer grows to hold all of it with no ceiling and no timeout. With multiplexing there is one such buffer per connection but nothing caps how many partial frames accumulate, so this is a cheap memory-exhaustion vector against a client that is only opted into an experimental flag. aiohttp's HTTP/1.1 parser enforces max_line_size/max_field_size for exactly this reason — the h2 parser should have equivalent limits.

RFC 9113 §4.2: a frame larger than the receiver's SETTINGS_MAX_FRAME_SIZE MUST be treated as a FRAME_SIZE_ERROR.

How to fix: reject with _protocol_error() when length > self.local_settings[Setting.MAX_FRAME_SIZE], before buffering. Worth also capping total buffered response bytes per stream so Stream.response_data cannot grow without limit (ClientResponse has read_bufsize/max_line_size equivalents; Http2Response has none).

            if len(self._frame_buffer) < FRAME_HEADER_LENGTH + length:
                break  # incomplete frame; wait for more data
6. os.getenv truthiness check means AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=0 enables h2
aiohttp/connector.py:950-952

os.getenv(...) returns a string, and every non-empty string is truthy. AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=0, =false, and =off all enable h2 negotiation.

Why it matters: an operator who explicitly disables the experimental protocol the obvious way silently turns it on and starts exercising an unfinished code path in production. An opt-in gate that cannot be turned off is worse than no gate.

How to fix: compare explicitly — if os.getenv("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS") == "1": — or accept a small truthy set. The False default is also the wrong type for getenv; drop it.

(Independently, @Dreamsorcerer asked for this to be a ClientSession parameter rather than an env var — see the reply on that thread.)

    protocols = ["http/1.1"]
    if os.getenv("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS", False):
        protocols += ["h2"]
7. The env var is only read at import time, so setting it from Python has no effect
aiohttp/connector.py:946-953

_make_ssl_context() is called exactly twice, at module import:

_SSL_CONTEXT_VERIFIED = _make_ssl_context(True)
_SSL_CONTEXT_UNVERIFIED = _make_ssl_context(False)

So the ALPN list — and therefore whether h2 is offered at all — is frozen when aiohttp.connector is first imported.

Why it matters: the most natural way a user enables an opt-in flag from Python is

import aiohttp
os.environ["AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS"] = "1"

which silently does nothing — no error, no warning, just HTTP/1.1 forever. The flag only works if set in the process environment before the first import aiohttp. Nothing in the PR description says this.

It also makes the feature awkward to test: any test that sets the env var via monkeypatch.setenv will not affect the already-built default contexts.

How to fix: decide the ALPN list per-connector rather than per-module (or, per @Dreamsorcerer, drive it from a ClientSession/TCPConnector parameter, which sidesteps the problem entirely). If the env var stays, document the import-time constraint explicitly.

        sslcontext.verify_mode = ssl.CERT_NONE
        sslcontext.options |= ssl.OP_NO_COMPRESSION
        sslcontext.set_default_verify_paths()

    protocols = ["http/1.1"]
    if os.getenv("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS", False):
        protocols += ["h2"]
    sslcontext.set_alpn_protocols(tuple(protocols))
8. Unused `time` and `deque` imports will fail flake8 in CI
aiohttp/client.py:10-13

import time and from collections import deque were added for the earlier connector._conns[key] = deque([(proto, time.monotonic())]) approach. That code has since been replaced by the connector._release(...) calls, and neither name is referenced anywhere in the current client.py diff.

Why it matters: flake8 runs on all files via pre-commit with no exclude for aiohttp/, so both trigger F401 imported but unused and the lint job fails before any reviewer sees the substance of the PR.

How to fix: drop both imports and run pre-commit run --all-files (per AGENTS.md) before the next push.

import time
import traceback
import warnings
from collections import deque

🟢 Suggestions

1. One shared flow-control Event wakes writers on unrelated streams
aiohttp/http2/connection.py:433-437

_flow_control_updated is a single connection-wide asyncio.Event, but the wait condition (stream.outbound_window <= 0 or self.session_outbound_window <= 0) is per-stream.

Consequences under real multiplexing — which is the entire point of the feature:

  • A WINDOW_UPDATE for stream 5 sets the shared event, waking every writer on streams 1, 3, 7… Each re-checks its own still-empty window, clears the event, and sleeps again — a thundering herd proportional to concurrent uploads.
  • Worse, a writer that clears the event immediately after another writer set it can swallow the wakeup for a third writer whose window did just open, stalling it until the next unrelated update arrives.
  • Line 467 (if self.session_outbound_window and stream.outbound_window: self._flow_control_updated.set()) sets the event based on one stream's state, compounding the same confusion.

Suggest a per-stream asyncio.Event (or future) for stream-window capacity plus one connection-level event for the session window, and waking only the streams whose window actually changed in _handle_window_update_frame.

            while stream.outbound_window <= 0 or self.session_outbound_window <= 0:
                self._flow_control_updated.clear()
                await self._flow_control_updated.wait()
2. Eager f-string formatting in per-frame logging
aiohttp/http2/connection.py:365-368

Thanks for commenting out the module-level logger.setLevel(logging.DEBUG) — that was the bigger problem and it's resolved.

The remaining cost is that _send_frame runs on every outbound frame and builds its message with an f-string, which Python evaluates before logger.debug decides whether the level is enabled. On a multiplexed connection with large uploads that is a formatted string (plus two format() alignment specs) per DATA frame, paid unconditionally.

data_received (line 138) already uses the lazy %s form correctly — worth applying the same treatment here and in connection_lost (logger.debug(f"Connection lost: {exc}")) and _handle_settings_frame (logger.info(f"Server SETTINGS: ...")).

Also consider dropping _send_frame's debug line to a logger.isEnabledFor(logging.DEBUG) guard, since it is genuinely per-frame.

        logger.debug(
            f"-> FRAME type={frame_type.name:>15} flags=0x{flags:02x} "
            f"stream_id={stream_id:<5} length={len(payload)}"
        )
3. hpack becomes an unconditional runtime dependency for a disabled-by-default feature
pyproject.toml:44

hpack >= 4.2.0 is added to dependencies, so every aiohttp install — including the overwhelming majority that will never set AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS — grows a new transitive dependency for code that is dead for them.

For a library with aiohttp's install base, adding a hard dependency for an experimental opt-in is a meaningful maintenance and supply-chain cost, and it is the kind of thing worth an explicit maintainer decision rather than a line in a feature PR.

Options, roughly in order of preference:

  • Ship it as an extra (aiohttp[http2]) and import hpack lazily inside http2/, raising a clear RuntimeError if the flag is set without the extra installed.
  • Import aiohttp.http2 lazily from http_protocol.py so a missing hpack is only fatal when h2 is actually negotiated. Note connector.py currently imports http_protocol at module scope, which imports http2.connection, which imports hpack — so today a missing hpack breaks import aiohttp entirely.

Minor: the entry is appended after yarl rather than inserted alphabetically (and without a trailing comma), unlike requirements/runtime-deps.in where it is correctly placed.

  "hpack >= 4.2.0"
4. Missing CHANGES fragment and documentation
aiohttp/http2/connection.py:1

AGENTS.md requires both for a change of this size, and neither is in the diff:

  • Changelog. CHANGES/5999.feature.rst (the referenced issue) plus a symlink from the PR number, signed -- by :user:`Moist-Cat`. The chronographer check will stay red without it.
  • Docs. "User-visible API changes need a docs update under docs/." A new opt-in protocol, a new response type, a new environment variable, and a new runtime dependency are all user-visible. At minimum a narrative page covering: how to enable it, that the flag must be set before import aiohttp (see the import-time finding), which ClientResponse features do and do not work, and the unsupported list from the PR description (proxies, chunking, CONTINUATION, h2c).
  • THREAT_MODEL.md. AGENTS.md asks for a revision when "a public API surface is added" and when parser configuration changes. A whole new wire parser reachable from the network qualifies — the frame-size and padding findings above are exactly the kind of thing that section exists to capture.

Noting this is a draft, so this is a pre-merge checklist item rather than something to fix right now.

5. Dead event_loop fixture, and url_mock never exercises the query/port code paths
tests/http2/test_http2.py:60-68

The coverage breadth here is genuinely good — frame-level compliance, state-machine transitions, and end-to-end ClientSession tests through a fake transport is more than most protocol PRs ship. A few things that reduce what it actually proves:

  • event_loop fixture is dead code. requirements/dev.txt pins pytest-asyncio==1.4.0, where the event_loop fixture was removed; redefining it no longer changes anything. Every test instead calls asyncio.get_running_loop() directly (which is correct). Delete the fixture.
  • @pytest.mark.asyncio is redundantsetup.cfg sets asyncio_mode = auto. Not wrong, just noise on ~50 tests.
  • url_mock() hides two real bugs. It sets query: None and has no raw_query_string, so the query-string branch in send_request is never executed; and it has no port and no raw_host, so the :authority bug flagged above is invisible. Using a real yarl.URL here would exercise both.
  • test_compressed_body_delivery's docstring says "delivers raw compressed bytes" but the assertion is resp.body == b"uncompressed", i.e. the opposite. Worth fixing so the intent is readable.
  • Uncovered paths that the findings above point at: unknown SETTINGS identifier (0x7), oversized pad_length, GOAWAY with non-UTF-8 debug data, truncated RST_STREAM/WINDOW_UPDATE payloads, and a frame declaring a length above MAX_FRAME_SIZE. Each is a two-line test against the existing connection fixture.

Also: tests/http2/ has no __init__.py; worth checking that matches how the rest of tests/ is laid out for the collector.

@pytest.fixture(scope="session")
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
6. `# HACK use the correct type` — two type: ignore comments papering over a real signature change
aiohttp/connector.py:1506-1507

_start_tls_connection and UnixConnector._create_connection are annotated to return ResponseHandler, but now return HttpDispatcherProtocol, so both got # type: ignore[return-value].

The author's own # HACK use the correct type comment says it: mypy is flagging a genuine model mismatch, not a false positive. Silencing it means the type system stops protecting the connector's contract — anything that later assumes ResponseHandler-specific members on these return values will fail at runtime rather than at check time.

If the dispatcher is unwrapped once ALPN is known (see the __getattribute__ finding), these signatures stay honest and both ignores disappear. Otherwise, introduce a ClientProtocol protocol/union type covering both handlers and widen the annotations to it.

Unrelated to either: the blank-line deletion at _create_direct_connection (before return transp, proto) is stray whitespace churn — worth reverting to keep the diff focused.

        # HACK use the correct type
        return tls_transport, tls_proto  # type: ignore[return-value]

Checklist

  • No hardcoded secrets or credentials
  • Untrusted network input is validated before use — warning #2, warning #3, warning #4, warning #5
  • Unbounded allocation from remote input is capped — warning #5
  • Errors do not escape asyncio transport callbacks — warning #2, warning #3
  • No resource leaks (connections, pool slots, buffers)
  • Opt-in feature flag cannot be bypassed or misread — warning #6, warning #7
  • No performance regression on the default (non-opt-in) path — critical #1, critical #2
  • Backward compatibility of public response API
  • New behaviour is covered by tests — suggestion #5
  • Lint passes (pre-commit run --all-files) — warning #8
  • Changelog fragment and docs updated per AGENTS.md — suggestion #4
  • Diff stays in scope — suggestion #6

To rebase and address feedback, mention me: @aiolibsbot rebase critical (fixes 🔴 only), @aiolibsbot rebase important (fixes 🔴 + 🟡), or @aiolibsbot rebase --fix for all. (A bare @aiolibsbot rebase only rebases onto the base branch.)

ℹ️ Triage summary

4 pre-existing finding(s) on unchanged code suppressed (freeze).


Silent Failure Analysis

🔴 **CRITICAL** — swallowed exception leaves shared state corrupted
aiohttp/http2/connection.py:246-254

Risk: HPACK uses a connection-wide dynamic table, so a failed decode desynchronises the decoder for every subsequent stream, yet this catch-all logs, resets one stream and lets the connection keep running — later responses silently decode into wrong headers instead of failing.

try:
    headers = self.hpack_decoder.decode(payload)
except Exception as exc:  # too general?
    logger.error(f"HPACK decode error: {exc}")
    self._send_rst_stream(stream_id, 1)  # PROTOCOL_ERROR
    return

Fix: Treat a decode failure as a connection error: send GOAWAY(COMPRESSION_ERROR), fail all outstanding stream futures, and close the transport rather than returning.

🔴 **CRITICAL** — unimplemented branch degraded to a log line
aiohttp/http2/connection.py:205-211

Risk: Dropping CONTINUATION frames silently discards the remainder of a header block that any server may split at will, and because the discarded fragment is never fed to the HPACK decoder the decoder state diverges permanently, corrupting all later responses with only a WARNING emitted.

elif frame_type in {
    FrameType.PRIORITY,
    FrameType.PUSH_PROMISE,
    FrameType.CONTINUATION,
}:
    logger.warning("%d frame ignored (not implemented)", frame_type)

Fix: Either implement CONTINUATION reassembly or fail loudly (GOAWAY + exception on the affected stream futures) when one is received; PRIORITY is safe to ignore, CONTINUATION is not.

🟠 **HIGH** — exception aborts cleanup loop, futures left pending forever
aiohttp/http2/connection.py:160-168

Risk: The pending-stream loop omits the done() guard used just above, so one already-cancelled/resolved future raises InvalidStateError inside connection_lost — asyncio swallows that into the loop exception handler and every remaining waiter in _pending_streams hangs forever with no connection left to serve it.

for stream in list(self.streams.values()):
    if not stream.response_future.done():
        stream.response_future.set_exception(ConnectionError("Connection lost"))
for fut in self._pending_streams:
    fut.set_exception(ConnectionError("Connection lost"))
self.streams.clear()

Fix: Guard with if not fut.done() (and clear _pending_streams afterwards) so every waiter is failed even if one future is already resolved.

🟠 **HIGH** — unvalidated input silently truncates data
aiohttp/http2/connection.py:230-236

Risk: When pad_length >= len(payload) (a protocol error the comment acknowledges but does not handle) the slice silently yields empty or wrongly-truncated bytes, so the caller receives a corrupted response body with no error at all.

if flags & FlagData.PADDED:
    pad_length = payload[0]
    pos = 1

# padding might be too long
data = payload[pos : len(payload) - pad_length]

Fix: Validate pad_length < len(payload) - 1 and raise a connection-level PROTOCOL_ERROR (GOAWAY) instead of slicing blindly.

🟠 **HIGH** — dead error branch / wrong validity check
aiohttp/http2/connection.py:295-303

Risk: The range check does not match the enum (0x0 and 0x7 are unassigned but pass <= 9), so Setting(identifier) raises ValueError for settings the RFC requires clients to ignore; the exception escapes data_received into asyncio's fatal-error path, killing the connection with an unrelated ConnectionError and never sending the SETTINGS ACK.

if identifier < 0 or identifier > 9:
    logger.warning("Unknown setting identifier %d", identifier)
    continue
setting = Setting(identifier)

Fix: Replace the numeric range test with membership (try: setting = Setting(identifier) / except ValueError: continue) so unknown settings are genuinely ignored.

🟠 **HIGH** — unguarded decode aborts GOAWAY handling
aiohttp/http2/connection.py:336-352

Risk: GOAWAY debug data is arbitrary bytes, so extra.decode() (evaluated eagerly, before the cancellation loop) can raise UnicodeDecodeError — and a short payload makes struct.unpack raise — aborting the handler before any stream is failed, so the real shutdown reason is replaced by an opaque transport error.

last_stream_id, error_code = struct.unpack("!I I", payload[:8])
...
logger.info(
    "GOAWAY received: last_stream=%d, error=%d, extra=%s",
    last_stream_id, error_code, extra.decode(),
)
# Cancel streams with higher IDs

Fix: Validate the payload length, use extra.decode(errors='replace') (or %r on the raw bytes), and perform the stream cancellation before any logging.

🟠 **HIGH** — resource double-release / pool corruption on error path
aiohttp/client.py:255-285

Risk: _release(should_close=False) appends the protocol to _conns[key], so releasing the same protocol twice per request leaves duplicate free-pool entries that grow unboundedly and hand the "same" connection to concurrent callers, while on the failure path conn.close() closes a protocol still sitting in that pool so later requests silently pick up a dead connection.

connector._release(conn._key, conn._protocol, should_close=False)
connector._acquired.add(conn._protocol)
...
connector._release(conn._key, conn._protocol, should_close=False)
conn._protocol = None

Fix: Track the h2 protocol explicitly (a dedicated per-host h2 registry) instead of round-tripping it through _release/_acquired, and ensure the error path removes it from _conns before closing.

🟡 **MEDIUM** — early return skips flow-control accounting
aiohttp/http2/connection.py:223-228

Risk: Bytes for an unknown/closed stream are dropped before session_inbound_window is decremented and before any connection-level WINDOW_UPDATE is emitted, so the client's and server's views of the session window silently diverge until the server stops sending and every request stalls.

stream = self.streams.get(stream_id)
if stream is None:
    if stream_id > self._last_peer_stream_id:
        self._send_rst_stream(stream_id, 1)  # PROTOCOL_ERROR
    return

Fix: Account for len(payload) against the session window (and emit the WINDOW_UPDATE) before returning early.

🟡 **MEDIUM** — unvalidated struct.unpack on attacker-controlled length
aiohttp/http2/connection.py:258-262

Risk: RST_STREAM and WINDOW_UPDATE payloads are unpacked without a length check, so a frame of any size other than 4 bytes raises struct.error out of data_received, which asyncio converts into a generic fatal transport error that hides the actual protocol violation from both logs and callers.

error_code = struct.unpack("!I", payload)[0]
...
increment = struct.unpack("!I", payload)[0]

Fix: Check len(payload) == 4 and emit an explicit FRAME_SIZE_ERROR (GOAWAY / RST_STREAM) instead of letting struct.error escape; also mask the reserved high bit of the WINDOW_UPDATE increment.

🟡 **MEDIUM** — fallback value hides malformed response
aiohttp/http2/response.py:40-41

Risk: A response missing the mandatory :status pseudo-header (a protocol violation) is silently reported to the caller as a legitimate HTTP 500 from the origin, making a client-side parsing bug indistinguishable from a real server error; a non-numeric value additionally raises a bare ValueError.

# no status error implies a server side error
self.status: int = int(self.headers.get(":status", 500))

Fix: Raise a protocol error when :status is absent or non-numeric rather than fabricating 500.

🟡 **MEDIUM** — unsupported case silently falls through
aiohttp/http2/response.py:28-32

Risk: Encodings aiohttp otherwise supports (br, zstd) fall through this check, so the still-compressed bytes are handed back as .body/.text()/.json() and surface later as a confusing decode/JSON error far from the cause.

encoding = self.headers.get("content-encoding", None)
if encoding in {"gzip", "deflate"}:
    comp = ZLibDecompressor(encoding=encoding)
    body = comp.decompress_sync(body)

Fix: Handle the remaining supported encodings (or raise ContentEncodingError for an unrecognised content-encoding) instead of returning the raw payload.

🟡 **MEDIUM** — silent no-op on always-null attribute
aiohttp/http2/response.py:120-122

Risk: self.connection is initialised to None and never assigned anywhere, so Http2Response.close() — including the call _connect_and_send_request makes on its error path — is unconditionally a no-op and the underlying stream/connection is never torn down.

def close(self) -> None:
    if self.connection:
        self.connection.close()

Fix: Wire the owning stream/protocol into the response and reset the stream (RST_STREAM) on close(), or make the method raise NotImplementedError until it is implemented.

🟡 **MEDIUM** — truthiness fallback misreads configuration
aiohttp/connector.py:949-952

Risk: os.getenv returns a string, so AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=0 / =false silently enables the experimental HTTP/2 path, and the default False (rather than None) hides that this is a string comparison at all.

protocols = ["http/1.1"]
if os.getenv("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS", False):
    protocols += ["h2"]

Fix: Parse the value explicitly, e.g. os.environ.get("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS", "").lower() in {"1", "true", "yes"}.


Automated review by Kōan (Claude) HEAD=742899f 11 min 35s

@Moist-Cat

Moist-Cat commented Aug 6, 2026

Copy link
Copy Markdown
Author

@egormanga In short, what impedes the support for h2c currently? I've familiarized myself with the patch and am willing to take on that.

I suppose you could add it without making major architectural changes. In principle, only two changes are needed:

  • Send the appropriate headers
  • Change the connector on-the-fly

For example, Session._request could have an upgrade parameter (default=False) that conditionally adds Upgrade and the rest of the required headers.
The main issue would be dynamically switching ResponseHandler for Http2Protocol without creating race conditions. Notice that when we send several request at once, we know if the server supports HTTP/2 only after the connection has been established. This means that, without synchronisation, we may send many HTTP/1.1 requests requesting an upgrade. If the server detects multiple TCP connections from the same IP talking HTTP/2, it might reset the stream. Not to mention how inefficient it is to send multiple upgrade requests.
To avoid concurrency issues, you can upgrade before leaving the Semaphore but you have to wrap the response inside the semaphore as well. Quoting the old RFC: "Requests that contain a payload body MUST be sent in their entirety before the client can send HTTP/2 frames.". This nulls all the performance gains until we change the code to wait only for the first response/connection made.

TL;DR: Add the headers to the request and switch protocols inside the Semaphore. Subsequent requests will reuse the HTTP/2 connection.

Considerations:

  • Never upgrade if the connection uses TLS (i.e., if the url says "http2" don't send the additional headers)
  • h2c seems to be obsolete as per the most recent RFC (unless I'm misinterpreting something)
  • Race conditions and performance

I would rather not implement this because, regardless of what we do with the semaphore, we have to wait until we get the first response from the server to know which protocol we should use. Notice that this doesn't happen if we negotiate the protocol with ALPN.

@Moist-Cat

Copy link
Copy Markdown
Author

I just noticed that I'm the one who said that support for h2c was missing. The rest of the features I mentioned (proxies, chunking, &c) are nice to have but it's probably better to implement them later.

@Dreamsorcerer

Copy link
Copy Markdown
Member

h2c is widely unsupported anyway (no major browser supports it), so we don't need to focus on that. If it's easy to add later, we can do so, but let's try not to expand the scope of this current work.

@egormanga

Copy link
Copy Markdown

h2c is not for browsers. It's often used as local inter-service protocol.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants